diff --git a/hooks/session_end.mjs b/hooks/session_end.mjs index 9704b72..1a9ac15 100644 --- a/hooks/session_end.mjs +++ b/hooks/session_end.mjs @@ -12,8 +12,7 @@ const data = pl.loadSession(sessionId); const host = data.host; if (host && pl.personaExists(host)) { - const state = pl.decayEmotion(pl.loadEmotion(host)); - pl.writeJson(pl.emotionPath(host), state); + const state = pl.updateEmotion(host, (s) => pl.decayEmotion(s)); pl.appendJsonl(pl.journalPath(host), { ts: pl.nowIso(), kind: "session-end", diff --git a/hooks/turn_end.mjs b/hooks/turn_end.mjs index 348a136..2d1d681 100644 --- a/hooks/turn_end.mjs +++ b/hooks/turn_end.mjs @@ -19,8 +19,7 @@ const host = data.host; if (!host || !pl.personaExists(host)) process.exit(0); pl.heartbeatLock(host, sessionId); -const state = pl.decayEmotion(pl.loadEmotion(host)); -pl.writeJson(pl.emotionPath(host), state); +const state = pl.updateEmotion(host, (s) => pl.decayEmotion(s)); const theater = Boolean(data.theater) && (data.rooms || []).length > 0; const message = event.last_assistant_message || ""; diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index c36811f..6cd7fca 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -138,14 +138,31 @@ export const AREA_KEYS = Object.keys(AREAS); export const syncDir = (slug, area) => path.join(pl.personaDir(slug), SYNC_DIRNAME, area); export const syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json"); -export function loadSyncState(slug) { - const data = pl.readJson(syncStatePath(slug), {}) ?? {}; - data.areas ??= {}; - for (const key of AREA_KEYS) data.areas[key] ??= {}; - return data; +/** 補上預設欄位。內容格式不變,只是保證 `state.areas[area]` 一定拿得到物件。 */ +function normalizeSyncState(data) { + const state = data && typeof data === "object" ? data : {}; + state.areas ??= {}; + for (const key of AREA_KEYS) state.areas[key] ??= {}; + return state; } -export const saveSyncState = (slug, data) => pl.writeJson(syncStatePath(slug), data); +export function loadSyncState(slug) { + return normalizeSyncState(pl.readJson(syncStatePath(slug), {}) ?? {}); +} + +/** + * `sync.json` 的 read-modify-write:整段在檔案鎖裡面做,`mutate(state)` 就地改就好。 + * + * 這個檔會被前景指令與背景 `sync push`(Stop hook 每輪都可能起一個)同時寫。 + * 以前是各自「讀出來、改幾筆、整份寫回去」,兩邊撞上時後寫的會把前一個的 + * `pushed_at`/`overwrites` 整段蓋掉——覆蓋紀錄就這樣安靜地消失。 + */ +export function updateSyncState(slug, mutate) { + return pl.updateJson(syncStatePath(slug), (data) => { + const state = normalizeSyncState(data); + return mutate(state) ?? state; + }, {}); +} // --------------------------------------------------------------------------- // // 環境與 API @@ -686,9 +703,9 @@ export async function pushArea(slug, area, { message = "", code = null, owner = gitOrThrow(["add", "-A"], dir, "git add"); const dirty = git(["diff", "--cached", "--quiet"], dir); if (dirty.ok) { - const state = loadSyncState(slug); - state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() }; - saveSyncState(slug, state); + updateSyncState(slug, (state) => { + state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() }; + }); return { ok: true, changed: false, files: staged.length, area, code: theCode }; } gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit"); @@ -728,16 +745,16 @@ export async function pushArea(slug, area, { message = "", code = null, owner = pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); } if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout }; - const state = loadSyncState(slug); - state.code = theCode; - state.owner = theOwner; - state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length }; - if (overwrote) { - // 每輪對話後的 push 是背景執行、輸出丟掉的,所以覆蓋紀錄一定要落地: - // 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。 - state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP); - } - saveSyncState(slug, state); + updateSyncState(slug, (state) => { + state.code = theCode; + state.owner = theOwner; + state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length }; + if (overwrote) { + // 每輪對話後的 push 是背景執行、輸出丟掉的,所以覆蓋紀錄一定要落地: + // 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。 + state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP); + } + }); return { ok: true, changed: true, files: staged.length, area, code: theCode, overwrote }; } @@ -748,14 +765,14 @@ export function pendingOverwrites(slug) { /** 標記為已回報(同一次覆蓋只吵一次)。回傳這次標掉幾筆。 */ export function markOverwritesReported(slug) { - const state = loadSyncState(slug); let marked = 0; - for (const entry of state.overwrites || []) { - if (entry.reported_at) continue; - entry.reported_at = pl.nowIso(); - marked += 1; - } - if (marked) saveSyncState(slug, state); + updateSyncState(slug, (state) => { + for (const entry of state.overwrites || []) { + if (entry.reported_at) continue; + entry.reported_at = pl.nowIso(); + marked += 1; + } + }); return marked; } @@ -798,9 +815,9 @@ export async function pullArea(slug, area, { code = null, owner = null, force = if (!fs.existsSync(path.join(root, cloneNameToRel(area, dir, name)))) restore.add(name); } const written = unstageArea(slug, area, dir, restore); - const state = loadSyncState(slug); - state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() }; - saveSyncState(slug, state); + updateSyncState(slug, (state) => { + state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() }; + }); return { ok: true, area, code: theCode, changed: incoming, written }; } @@ -923,12 +940,12 @@ export async function importFromRemote(code, { owner = null, slug = null, force } catch { /* 沒有關係圖就算了 */ } - const state = loadSyncState(target); - state.code = code; - state.owner = theOwner; - if (repo?.html_url) state.repo_url = repo.html_url; - state.imported_at = pl.nowIso(); - saveSyncState(target, state); + updateSyncState(target, (state) => { + state.code = code; + state.owner = theOwner; + if (repo?.html_url) state.repo_url = repo.html_url; + state.imported_at = pl.nowIso(); + }); const written = [...new Set(AREA_KEYS.flatMap((key) => results[key]?.written || []))].sort(); return { persona: target, code, owner: theOwner, repo, results, written, overwrote_local: !fresh }; } @@ -954,12 +971,12 @@ export async function initRemote(slug, { code = null, owner = null, private_ = t message: `init(${area}): ${AREAS[area].why}`, }); } - const state = loadSyncState(slug); - state.code = theCode; - state.owner = theOwner; - state.repo_url = repo.html_url; - state.initialized_at = state.initialized_at || pl.nowIso(); - saveSyncState(slug, state); + updateSyncState(slug, (state) => { + state.code = theCode; + state.owner = theOwner; + state.repo_url = repo.html_url; + state.initialized_at = state.initialized_at || pl.nowIso(); + }); return { code: theCode, owner: theOwner, repo, created, wikiCreated, results }; } diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 0a3ec6a..975f0b8 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -541,8 +541,7 @@ commands.sleep = async ({ flags }) => { await run("prune-short-term", () => pl.pruneShortTermDetail(slug)); await run("archive-threads", () => pl.archiveStaleThreads(slug)); await run("emotion-decay", () => { - const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES); - pl.writeJson(pl.emotionPath(slug), state); + pl.updateEmotion(slug, (s) => pl.decayEmotionBy(s, pl.SLEEP_DECAY_MINUTES)); }); await run("reindex", () => pl.rebuildIndex(slug)); await run("trim-said", () => pl.trimSaid(slug)); @@ -744,8 +743,7 @@ commands.remember = ({ flags }) => { pl.rememberShort(slug, entry); const kept = pl.pruneShortTerm(slug); if (Object.keys(entry.emotion_deltas).length) { - const state = pl.applyEmotion(pl.loadEmotion(slug), entry.emotion_deltas, text.slice(0, 80)); - pl.writeJson(pl.emotionPath(slug), state); + const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80))); pl.appendJsonl(pl.journalPath(slug), { ts: pl.nowIso(), kind: "emotion", trigger: text.slice(0, 120), deltas: entry.emotion_deltas, levels: state.levels, mood: pl.mood(state), diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index cdffc07..15a2e87 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -870,6 +870,23 @@ console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的 const parallel = joyOf(); check("並行 8 次 `emotion --apply` 跟循序 8 次結果一樣(情緒更新不遺失)", Math.abs(parallel - sequential) < 0.01, `循序 ${sequential} / 並行 ${parallel}`); + + // ②-b `remember --emotion` 是每一輪對話都會走的熱路徑,它的情緒寫入也必須在鎖裡 + const sayArgs = ["remember", "--session", "sess-race-9999", "--role", "user", + "--text", "熱路徑", "--salience", "10", "--emotion", "joy=+5"]; + resetJoy(); + for (let i = 0; i < 8; i += 1) cli(sayArgs); + const saySequential = 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)}, ...${JSON.stringify(sayArgs)}], { stdio: "ignore" });`))); + const sayParallel = joyOf(); + check("並行 8 次 `remember --emotion`(對話熱路徑)跟循序結果一樣", + Math.abs(sayParallel - saySequential) < 0.01, `循序 ${saySequential} / 並行 ${sayParallel}`); + cli(["release", "--session", "sess-race-9999"]); // ③ withFileLock 本身:N 個程序各做一次 read-increment-write,一次都不能掉