fix(data): 四處情緒/sync 狀態的讀改寫還沒進鎖,等於繞過了上次的修正
B4 加了 updateJson/updateEmotion,但這幾個呼叫點還是「loadEmotion → 改 → writeJson」的裸讀改寫,鎖形同不存在: - sleep 的 emotion-decay、remember --emotion(每輪對話的熱路徑)、 Stop hook 與 SessionEnd hook 的時間衰減,四處都改走 pl.updateEmotion()。 衰減與 delta 的算法一個字都沒動,只是把讀與寫收進同一把鎖裡。 - sync.json 同理:背景 sync push 跟前景指令會同時寫它,兩邊撞上時後寫的會把 pushed_at/overwrites 整段蓋掉——覆蓋紀錄就這樣安靜地消失。新增 updateSyncState()(帶鎖),六處 loadSyncState + saveSyncState 成對使用全部 改過去;補預設欄位的邏輯抽成 normalizeSyncState() 給兩邊共用。 saveSyncState 沒有呼叫端了,直接移掉,免得下次又有人拿它裸寫。 sync.json 的欄位格式與 overwrites 留 10 筆的行為都沒變。 selftest 448 項(+1):新增「並行 8 次 remember --emotion 跟循序結果一樣」, 把對話熱路徑的情緒寫入也蓋進競態測試——原本只蓋到 emotion --apply。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+58
-41
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user